package org.dyndns.delphyne.couchdb

import org.dyndns.delphyne.couchdb.exception.DatabaseNotFoundException
import org.dyndns.delphyne.couchdb.exception.ObjectDeletedException
import org.dyndns.delphyne.couchdb.exception.ObjectNotFoundException

import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import groovy.util.logging.Slf4j
import groovyx.net.http.HttpResponseException
import groovyx.net.http.RESTClient

/**
 * This class is Mixed into all classes annotated by Document in order to provide the create, update, retrieve, and 
 *  delete methods.
 * @author bcarr
 *
 */
@Slf4j
class Repository {
    static RepositoryConfig config

    RESTClient couch

    Repository() {
        couch = new RESTClient(config.url)
        couch.parser
        couch.auth.basic(config.apikey.user, config.apikey.password)
    }

    /**
     * Find a Document on the table represented by the provided ID.  This method is provided by the Repository class,
     *  and is mixed-in to all {@link Document} annotated classes.
     * @param id
     * @return
     */
    def find() {
        findDocument(this.class, this._id)
    }

    /**
     * Find a Document matching the provided type and ID.
     * @param type
     * @param id
     * @return
     */
    public <T> T findDocument(Class<T> type, String id, boolean suppressNotFoundExceptions = true) {
        try {
            def response = couch.get(path: "${type.simpleName.toLowerCase()}/${id}", contentType: 'text/plain')
            JsonSlurper slurper = new JsonSlurper()
            type.newInstance(slurper.parse(response.data))
        } catch(HttpResponseException ex) {
            def mappedException = translateException(ex)
            
            if (!(suppressNotFoundExceptions && (mappedException instanceof ObjectNotFoundException))) {
                throw mappedException
            }
        }
    }

    /**
     * Saves the current document to the database.  This method is provided by the Repository Class, and is mixed-in
     *  to all {@link Document} annotated classes.
     * @param instance
     * @return
     */
    def save() {
        saveDocument(this.class, this)
    }

    /**
     * Saves a document with the provided type to a database within couchdb.  If the document does not yet have an
     *  ID, one will be generated by CouchDB for you.  The object returned is a new object with the _id and _rev
     *  fields populated from the CouchDB response.
     * @param type
     * @param instance
     * @return
     */
    public <T> T saveDocument(Class<T> type, T instance) {
        try {
            def propMap = toMap(instance)
            def json = JsonOutput.toJson(propMap)

            def response
            if (instance._id == null) {
                response = couch.post(
                        path: "${type.simpleName.toLowerCase()}/",
                        contentType: 'text/plain',
                        requestContentType: 'application/json',
                        body: json)
            } else {
                response = couch.put(
                        path: "${type.simpleName.toLowerCase()}/${instance._id}",
                        contentType: 'text/plain',
                        requestContentType: 'application/json',
                        body: json)
            }

            JsonSlurper slurper = new JsonSlurper()
            def responseMap = slurper.parse(response.data)
            propMap._id = responseMap.id
            propMap._rev = responseMap.rev
            type.newInstance(propMap)
        } catch (HttpResponseException ex) {
            throw translateException(ex)
        }
    }

    /**
     * Deletes the current instance from CouchDB.  A revision is required to perform the delete.  If no revision is
     *  provided in the object instance, a search will be performed to find the most recent revision, then the 
     *  delete will be performed against that revision.  This method is provided by the Repository class, and is 
     *  mixed-in to all {@link Document} annotated classes. 
     * @param person
     * @param findRevisionToDelete
     * @return the revision of the deleted document
     */
    String delete() {
        deleteDocument(this.class, this)
    }

    /**
     * Deletes the current document from CouchDB.  If no _rev is supplied, a search is performed before executing the
     *  delete.
     * @param type
     * @param instance
     * @return
     */
    public <T> String deleteDocument(Class<T> type, T instance) {
        try {
            def instanceToDelete = instance._rev ? instance : find(type, instance.id, false)

            def response = couch.delete(
                    path: "${type.simpleName.toLowerCase()}/${instanceToDelete._id}",
                    query: [rev: instanceToDelete._rev],
                    contentType: 'text/plain')

            JsonSlurper slurper = new JsonSlurper()
            def responseMap = slurper.parse(response.data)
            responseMap.rev
        } catch (HttpResponseException ex) {
            translateException(ex)
        }
    }


    /**
     * Creates a map from an Object instance's properties, excluding null properties, and the class and metaClass 
     *  properties.
     * @param instance
     * @return
     */
    private toMap(def instance) {
        instance.properties.inject([:]) { acc, it ->
            if (it.value == null) {
                log.trace "excluding ${it.key} due to null value"
                acc
            } else if (it.key ==~ /class|metaClass/) {
                log.trace "suppressing ${it.key}"
                acc
            } else {
                acc << it
            }
        }
    }

    /**
     * Maps HTTP Response codes and the JSON emitted from CouchDB into exceptions
     * @param ex
     * @return
     */
    private Exception translateException(HttpResponseException ex) {
        JsonSlurper slurper = new JsonSlurper()
        def responseMap = slurper.parse(ex.response.data)
        switch(ex.response.status) {
            case 404:
                if (responseMap.reason == 'missing') {
                    new ObjectNotFoundException(ex)
                } else if (responseMap.reason == 'deleted') {
                    new ObjectDeletedException(ex)
                } else if (responseMap.reason == 'no_db_file') {
                    new DatabaseNotFoundException(ex)
                } else {
                    ex
                }
                break
            default:
                ex
        }
    }
}

class RepositoryConfig {
    String url
    APIKey apikey

    class APIKey {
        String user
        String password
    }
}